#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Calibre-Web Automated – fork of Calibre-Web
# Copyright (C) 2018-2025 Calibre-Web contributors
# Copyright (C) 2024-2025 Calibre-Web Automated contributors
# SPDX-License-Identifier: GPL-3.0-or-later
# See CONTRIBUTORS for full list of authors.

"""
KOReader Sync Server Implementation for Calibre-Web-Automated

This module provides a sync server compatible with KOReader's sync functionality,
allowing users to sync their reading progress across devices.

Protocol Specification:
    - Authentication: HTTP Basic Auth (RFC 7617)
    - Endpoints:
        * GET  /kosync/users/auth - Authenticate user
        * GET  /kosync/syncs/progress/<document> - Get reading progress
        * PUT  /kosync/syncs/progress - Update reading progress
        * GET  /kosync - Plugin download page

Security:
    - All API endpoints use HTTP Basic Authentication
    - Document identifiers validated to prevent injection attacks
    - Session management via SQLAlchemy with proper isolation
    - Rate limiting should be applied at reverse proxy level

Integration:
    - Syncs with Calibre library via BookFormatChecksum table
    - Updates ReadBook status based on reading percentage thresholds
    - Maintains KoboReadingState for compatibility with Kobo sync
    - Atomic commits ensure sync data integrity

Based on the reference implementation from koreader-sync-server
Reference: https://github.com/koreader/koreader-sync-server
"""

import base64
from datetime import datetime, timezone
from typing import Dict, Optional, Any, Tuple

from flask import Blueprint, request, jsonify
from flask_babel import gettext as _
from werkzeug.security import check_password_hash
from sqlalchemy import func
from sqlalchemy.exc import SQLAlchemyError

from ... import logger, ub, csrf, config, constants, services
from ...render_template import render_title_template
from ..models import KOSyncProgress

log = logger.create()

# Create the blueprint
kosync = Blueprint('kosync', __name__)

# Error codes matching KOReader sync server specification
ERROR_NO_STORAGE = 1000
ERROR_INTERNAL = 2000
ERROR_UNAUTHORIZED_USER = 2001
ERROR_USER_EXISTS = 2002
ERROR_INVALID_FIELDS = 2003
ERROR_DOCUMENT_FIELD_MISSING = 2004

# Field names (constants for API contract)
PROGRESS_FIELD = "progress"
PERCENTAGE_FIELD = "percentage"
DEVICE_FIELD = "device"
DEVICE_ID_FIELD = "device_id"
TIMESTAMP_FIELD = "timestamp"

# Validation constants
MAX_DOCUMENT_LENGTH = 255  # Maximum document identifier length
MAX_PROGRESS_LENGTH = 255  # Maximum progress string length
MAX_DEVICE_LENGTH = 100    # Maximum device name length
MAX_DEVICE_ID_LENGTH = 100 # Maximum device ID length


class KOSyncError(Exception):
    """Custom exception for KOSync protocol errors"""
    def __init__(self, error_code: int, message: str):
        self.error_code = error_code
        self.message = message
        super().__init__(message)


def is_valid_field(field: Any) -> bool:
    """
    Check if a field is valid (not None, not empty string).

    Args:
        field: Value to validate

    Returns:
        True if field is a non-empty string
    """
    return isinstance(field, str) and len(field) > 0


def is_valid_key_field(field: Any, max_length: int = MAX_DOCUMENT_LENGTH) -> bool:
    """
    Check if a field is valid as a database key.

    Key fields must be non-empty strings without colons (reserved for internal use)
    and within specified length limits.

    Args:
        field: Value to validate
        max_length: Maximum allowed length

    Returns:
        True if field is valid for use as a key
    """
    return is_valid_field(field) and ":" not in field and len(field) <= max_length


def authenticate_user() -> Optional[ub.User]:
    """
    Authenticate user using HTTP Basic Authentication (RFC 7617).

    Expects Authorization header with format: 'Basic <base64(username:password)>'

    Security considerations:
        - Uses constant-time password comparison via check_password_hash
        - Case-insensitive username lookup for consistency with Calibre-Web
        - Validates credential format before database lookup

    Returns:
        User object if authentication succeeds, None otherwise
    """
    auth_header = request.headers.get('Authorization')

    if not auth_header or not auth_header.startswith('Basic '):
        log.debug("Missing or invalid Authorization header")
        return None

    try:
        # Extract and decode the base64 encoded credentials
        encoded_credentials = auth_header[6:]  # Remove 'Basic ' prefix
        decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')

        # Split username and password (allow colons in password)
        if ':' not in decoded_credentials:
            log.debug("Invalid credential format (missing colon separator)")
            return None

        username, password = decoded_credentials.split(':', 1)

    except (ValueError, UnicodeDecodeError) as e:
        log.warning(f"Failed to decode credentials: {str(e)}")
        return None

    # Validate field formats before database lookup
    if not is_valid_field(password) or not is_valid_key_field(username, max_length=MAX_DEVICE_LENGTH):
        log.debug(f"Invalid username or password format")
        return None

    # Find user by username (case-insensitive for Calibre-Web compatibility)
    try:
        user = ub.session.query(ub.User).filter(
            func.lower(ub.User.name) == username.lower()
        ).first()
    except SQLAlchemyError as e:
        log.error(f"Database error during user lookup: {e}")
        return None

    if not user:
        log.debug(f"User not found: {username}")
        return None

    # Check if LDAP authentication is enabled
    if config.config_login_type == constants.LOGIN_LDAP and services.ldap:
        # Try LDAP authentication
        login_result, error = services.ldap.bind_user(user.name, password)
        if login_result:
            log.info(f"authenticate_user: Successfully authenticated user via LDAP: {user.name}")
            return user
        
        # Log LDAP failure but continue to local check (fallback)
        # We use debug level here because failure is expected if the user is using a local password
        if error:
            log.debug(f"authenticate_user: LDAP authentication failed for {user.name} (attempting local fallback): {error}")

    # Verify password using constant-time comparison
    # Check if user has a local password set before attempting verification
    if user.password and check_password_hash(str(user.password), password):
        log.info(f"User authenticated successfully: {username}")
        return user

    log.debug(f"Invalid password for user: {username}")
    return None


def create_sync_response(data: Dict[str, Any], status_code: int = 200) -> tuple:
    """
    Create a standardized JSON sync response.

    Args:
        data: Response payload dictionary
        status_code: HTTP status code (default: 200)

    Returns:
        Tuple of (response, status_code) for Flask
    """
    return jsonify(data), status_code


def handle_sync_error(error: KOSyncError) -> tuple:
    """
    Handle sync errors and return appropriate response.

    Args:
        error: KOSyncError with error code and message

    Returns:
        JSON error response with 400 status code
    """
    log.error(f"KOSync Error {error.error_code}: {error.message}")
    return create_sync_response({
        "error": error.error_code,
        "message": error.message
    }, 400)


def get_book_by_checksum(document_checksum: str, version: str = None):
    """
    Lookup a book in the Calibre library by its partial MD5 checksum.

    Searches all stored checksums for the given checksum value, regardless of
    whether it came from library files or OPDS exports.

    Args:
        document_checksum: The partial MD5 checksum from KOReader
        version: Optional algorithm version to filter by (None = any version)

    Returns:
        Tuple of (book_id, book_format, book_title, book_path, version) or
        (None, None, None, None, None) if no match found

    Note:
        Uses parameterized queries to prevent SQL injection.
        Orders by created DESC (latest first), then version DESC.
    """
    from ... import calibre_db
    from ...db import BookFormatChecksum, Books

    try:
        query = calibre_db.session.query(
            BookFormatChecksum.book,
            BookFormatChecksum.format,
            BookFormatChecksum.version,
            Books.title,
            Books.path
        ).join(
            Books, BookFormatChecksum.book == Books.id
        ).filter(
            BookFormatChecksum.checksum == document_checksum
        )

        # Optionally filter by version
        if version is not None:
            query = query.filter(BookFormatChecksum.version == version)

        # Order by created DESC (latest first), then version DESC
        query = query.order_by(
            BookFormatChecksum.created.desc(),
            BookFormatChecksum.version.desc()
        )

        result = query.first()

        if result:
            book_id, book_format, checksum_version, book_title, book_path = result
            log.debug(f"Found book match: {book_title} (ID {book_id}, format {book_format}, checksum v{checksum_version})")
            return book_id, book_format, book_title, book_path, checksum_version

        # No match found
        log.debug(f"No book found for checksum: {document_checksum}")
        return None, None, None, None, None

    except SQLAlchemyError as e:
        log.error(f"Database error looking up book by checksum {document_checksum}: {e}")
        return None, None, None, None, None
    except Exception as e:
        log.error(f"Unexpected error looking up book by checksum {document_checksum}: {e}")
        return None, None, None, None, None


def enrich_response_with_book_info(response_data: Dict[str, Any], document_checksum: str) -> Dict[str, Any]:
    """
    Enrich a sync response with Calibre book information if the book is found.

    This adds Calibre-specific metadata to the response, allowing clients to
    display richer information about the synced document.

    Args:
        response_data: The response dictionary to enrich
        document_checksum: The document checksum to look up

    Returns:
        Tuple of (enriched_response_data, book_id, book_format, book_title, checksum_version)
    """
    book_id, book_format, book_title, book_path, checksum_version = get_book_by_checksum(document_checksum)

    if book_id:
        response_data["calibre_book_id"] = book_id
        response_data["calibre_book_title"] = book_title
        response_data["calibre_book_format"] = book_format
        response_data["calibre_checksum_version"] = checksum_version

    return response_data, book_id, book_format, book_title, checksum_version


def update_book_read_status(user_id: int, book_id: int, percentage: float) -> None:
    """
    Update the user's ReadBook status based on reading progress percentage.

    Status thresholds:
        - 0%: STATUS_UNREAD
        - 1-98%: STATUS_IN_PROGRESS
        - 99-100%: STATUS_FINISHED

    Behavior:
        - Creates ReadBook record if it doesn't exist
        - Increments times_started_reading when transitioning to IN_PROGRESS
        - Updates KoboBookmark progress_percent for Kobo sync compatibility
        - Handles status transitions gracefully

    Args:
        user_id: The ID of the user
        book_id: The ID of the book in the Calibre library
        percentage: Reading progress percentage (0.0 to 100.0)

    Raises:
        SQLAlchemyError: If database operation fails

    Note:
        Caller is responsible for committing the session.
    """
    # Determine the new read status based on percentage
    if percentage >= 99.0:
        new_status = ub.ReadBook.STATUS_FINISHED
    elif percentage > 0:
        new_status = ub.ReadBook.STATUS_IN_PROGRESS
    else:
        new_status = ub.ReadBook.STATUS_UNREAD

    log.debug(f"update_book_read_status: user {user_id}, book {book_id}, "
              f"percentage {percentage:.2f}% -> status {new_status}")

    # Query for existing ReadBook record
    book_read = ub.session.query(ub.ReadBook).filter(
        ub.ReadBook.user_id == user_id,
        ub.ReadBook.book_id == book_id
    ).first()

    if book_read:
        # Update existing record
        old_status = book_read.read_status
        log.debug(f"Found existing ReadBook: old_status={old_status}, new_status={new_status}")

        # Increment times_started_reading when transitioning to IN_PROGRESS
        if new_status == ub.ReadBook.STATUS_IN_PROGRESS and old_status != ub.ReadBook.STATUS_IN_PROGRESS:
            book_read.times_started_reading += 1
            book_read.last_time_started_reading = datetime.now(timezone.utc)
            log.info(f"User {user_id} started reading book {book_id} "
                    f"(times started: {book_read.times_started_reading})")

        # Update status if changed
        if old_status != new_status:
            book_read.read_status = new_status
            log.info(f"User {user_id} book {book_id} status changed: "
                    f"{old_status} -> {new_status} (progress: {percentage:.1f}%)")
        else:
            log.debug(f"ReadBook status unchanged: {old_status}")

        book_read.last_modified = datetime.now(timezone.utc)

        # Update KoboBookmark progress_percent if it exists
        if book_read.kobo_reading_state and book_read.kobo_reading_state.current_bookmark:
            book_read.kobo_reading_state.current_bookmark.progress_percent = percentage
            book_read.kobo_reading_state.current_bookmark.last_modified = datetime.now(timezone.utc)

    else:
        # Create new ReadBook record
        book_read = ub.ReadBook(
            user_id=user_id,
            book_id=book_id,
            read_status=new_status
        )

        # Set started reading fields for IN_PROGRESS books
        # Note: Following Kobo/CWA convention, times_started_reading only increments
        # when status is IN_PROGRESS. Books that jump straight to FINISHED (e.g.,
        # syncing at 100% without intermediate syncs) will have times_started_reading=0
        if new_status == ub.ReadBook.STATUS_IN_PROGRESS:
            book_read.times_started_reading = 1
            book_read.last_time_started_reading = datetime.now(timezone.utc)
            log.info(f"User {user_id} started reading book {book_id} (new entry)")

        # Create associated KoboReadingState
        kobo_reading_state = ub.KoboReadingState(
            user_id=user_id,
            book_id=book_id
        )
        kobo_reading_state.current_bookmark = ub.KoboBookmark()
        kobo_reading_state.current_bookmark.progress_percent = percentage
        kobo_reading_state.statistics = ub.KoboStatistics()
        book_read.kobo_reading_state = kobo_reading_state

        ub.session.add(book_read)
        log.info(f"User {user_id} book {book_id} created with status {new_status} "
                f"(progress: {percentage:.1f}%)")

    # Merge the record (caller commits)
    ub.session.merge(book_read)


################################################################################
# API Endpoints
################################################################################

@kosync.route("/kosync")
def kosync_plugin_page():
    """
    Display the KOReader plugin download and installation page.

    This page provides:
        - Plugin download link
        - Installation instructions
        - Configuration guidance
        - Troubleshooting tips

    Returns:
        Rendered HTML page
    """
    return render_title_template(
        "kosync_plugin.html",
        title=_("KOReader Sync Plugin"),
        page="cwa-kosync"
    )


@csrf.exempt
@kosync.route("/kosync/users/auth", methods=["GET"])
def auth_user():
    """
    Authenticate user endpoint (KOSync protocol).

    This endpoint verifies user credentials and is typically called once
    during KOReader sync setup to validate the connection.

    Returns:
        200: {"authorized": "OK"} if authentication succeeds
        401: {"error": 2001, "message": "Unauthorized"} if authentication fails

    Note:
        Rate limiting should be applied at reverse proxy level to prevent
        brute force attacks (suggested: 10 requests per minute per IP).
    """
    user = authenticate_user()
    if user:
        return create_sync_response({"authorized": "OK"})
    else:
        return create_sync_response({
            "error": ERROR_UNAUTHORIZED_USER,
            "message": "Unauthorized"
        }, 401)

@csrf.exempt
@kosync.route("/kosync/syncs/progress/<document>", methods=["GET"])
def get_progress(document: str):
    """
    Get reading progress for a document (KOSync protocol).

    Returns the latest progress for the specified document identifier,
    enriched with Calibre library metadata if the book is matched.

    Args:
        document: Document identifier (KOReader partial MD5 hash)

    Returns:
        200: Progress data with optional Calibre metadata
        400: Error response if validation fails
        401: Unauthorized if authentication fails

    Response format:
        {
            "document": "abc123...",
            "progress": "location string",
            "percentage": 0.4567,  # Decimal fraction (0.4567 = 45.67%)
            "device": "KOReader",
            "device_id": "device123",
            "timestamp": 1699564800,
            "calibre_book_id": 42,  # Optional: if matched
            "calibre_book_title": "Book Title",  # Optional
            "calibre_book_format": "EPUB",  # Optional
            "calibre_checksum_version": "koreader"  # Optional
        }

    Note:
        Percentage is returned as decimal (0.4567 = 45.67%) as expected by KOReader.
        Internally stored as percentage (0-100) in database.
    """
    try:
        user = authenticate_user()
        if not user:
            raise KOSyncError(ERROR_UNAUTHORIZED_USER, "Unauthorized")

        if not is_valid_key_field(document):
            raise KOSyncError(ERROR_DOCUMENT_FIELD_MISSING, "Invalid document field")

        # Query progress from database
        progress_record = ub.session.query(KOSyncProgress).filter(
            KOSyncProgress.user_id == user.id,
            KOSyncProgress.document == document
        ).first()

        if not progress_record:
            log.debug(f"No progress found for user {user.id}, document {document}")
            return create_sync_response({})

        # KOReader expects percentage as a decimal fraction (0.9411 = 94.11%)
        # We store it as percentage (0-100), so convert back to decimal (0-1)
        percentage_decimal = progress_record.percentage / 100.0

        response_data = {
            "document": document,
            "progress": progress_record.progress,
            "percentage": percentage_decimal,
            "device": progress_record.device,
            "device_id": progress_record.device_id,
            "timestamp": int(progress_record.timestamp.timestamp())
        }

        # Enrich response with Calibre book information if available
        response_data, book_id, book_format, book_title, _ = enrich_response_with_book_info(
            response_data, document
        )

        return create_sync_response(response_data)

    except KOSyncError as e:
        return handle_sync_error(e)
    except SQLAlchemyError as e:
        log.error(f"get_progress: Database error: {str(e)}")
        return handle_sync_error(KOSyncError(ERROR_INTERNAL, "Database error"))
    except Exception as e:
        log.error(f"get_progress: Unexpected error: {str(e)}")
        return handle_sync_error(KOSyncError(ERROR_INTERNAL, "Internal server error"))


@csrf.exempt
@kosync.route("/kosync/syncs/progress", methods=["PUT"])
def update_progress():
    """
    Update reading progress for a document (KOSync protocol).

    This endpoint receives progress updates from KOReader devices and:
        1. Validates and stores the sync data in kosync_progress table
        2. Attempts to match the document to a Calibre library book
        3. Updates ReadBook status if a match is found

    The commit strategy ensures sync data is always persisted, even if
    ReadBook updates fail (preventing sync data loss).

    Request body:
        {
            "document": "abc123...",  # Required: Document identifier
            "progress": "location",   # Required: Current reading position
            "percentage": 0.4567,     # Required: Progress as decimal (0-1)
            "device": "KOReader",     # Required: Device name
            "device_id": "device123"  # Optional: Device identifier
        }

    Returns:
        200: Success with document and timestamp
        400: Validation error
        401: Unauthorized
        500: Internal error

    Response format:
        {
            "document": "abc123...",
            "timestamp": 1699564800,
            "calibre_book_id": 42,  # Optional: if matched
            "calibre_book_title": "Book Title",  # Optional
            "calibre_book_format": "EPUB",  # Optional
            "calibre_checksum_version": "koreader"  # Optional
        }

    Note:
        Percentage is converted from decimal (0.9411 = 94.11%) to percentage (94.11).
    """
    try:
        user = authenticate_user()
        if not user:
            raise KOSyncError(ERROR_UNAUTHORIZED_USER, "Unauthorized")

        data = request.get_json()
        if not data:
            raise KOSyncError(ERROR_INVALID_FIELDS, "Invalid request data")

        # Extract and validate required fields
        document = data.get("document")
        if not is_valid_key_field(document):
            raise KOSyncError(ERROR_DOCUMENT_FIELD_MISSING, "Invalid document field")

        progress = data.get("progress")
        percentage = data.get("percentage")
        device = data.get("device")
        device_id = data.get("device_id")

        # Validate required fields
        if not progress or percentage is None or not device:
            raise KOSyncError(ERROR_INVALID_FIELDS, "Missing required fields")

        # Validate field lengths
        if not is_valid_field(progress) or len(progress) > MAX_PROGRESS_LENGTH:
            raise KOSyncError(ERROR_INVALID_FIELDS, "Invalid progress field")
        if not is_valid_field(device) or len(device) > MAX_DEVICE_LENGTH:
            raise KOSyncError(ERROR_INVALID_FIELDS, "Invalid device field")
        if device_id and len(device_id) > MAX_DEVICE_ID_LENGTH:
            raise KOSyncError(ERROR_INVALID_FIELDS, "Invalid device_id field")

        # KOReader sends percentage as a decimal fraction (0.9411 = 94.11%)
        # Convert to actual percentage (0-100 range)
        try:
            percentage_float = float(percentage)
            if percentage_float <= 1.0:
                percentage_float *= 100.0
            if percentage_float < 0 or percentage_float > 100:
                raise ValueError("Percentage out of range")
        except (ValueError, TypeError) as e:
            raise KOSyncError(ERROR_INVALID_FIELDS, f"Invalid percentage value: {e}")

        timestamp = datetime.now(timezone.utc)

        # Check if progress record exists
        progress_record = ub.session.query(KOSyncProgress).filter(
            KOSyncProgress.user_id == user.id,
            KOSyncProgress.document == document
        ).first()

        if progress_record:
            # Update existing record
            progress_record.progress = progress
            progress_record.percentage = percentage_float
            progress_record.device = device
            progress_record.device_id = device_id
            progress_record.timestamp = timestamp
            log.debug(f"Updated kosync progress for user {user.id}, document {document}")
        else:
            # Create new record
            progress_record = KOSyncProgress(
                user_id=user.id,
                document=document,
                progress=progress,
                percentage=percentage_float,
                device=device,
                device_id=device_id,
                timestamp=timestamp
            )
            ub.session.add(progress_record)
            log.debug(f"Created kosync progress for user {user.id}, document {document}")

        # CRITICAL: Always commit kosync_progress first before attempting ReadBook updates
        # This ensures sync location is persisted even if ReadBook update fails
        try:
            ub.session.commit()
            log.info(f"Saved kosync progress: user={user.id}, document={document}, "
                    f"progress={percentage_float:.2f}%")
        except SQLAlchemyError as e:
            log.error(f"Failed to commit kosync_progress: {e}")
            ub.session.rollback()
            raise KOSyncError(ERROR_INTERNAL, "Failed to save sync progress")

        response_data = {
            "document": document,
            "timestamp": int(timestamp.timestamp())
        }

        # Enrich response with Calibre book information if available
        response_data, book_id, book_format, book_title, _ = enrich_response_with_book_info(
            response_data, document
        )

        # Update user's ReadBook status if we matched a book
        # This is done AFTER kosync_progress is committed, so sync location is always safe
        if book_id:
            try:
                update_book_read_status(user.id, book_id, percentage_float)
                ub.session.commit()
                log.info(f"Updated ReadBook status: user={user.id}, book={book_id} "
                        f"({book_title}), status based on {percentage_float:.1f}%")
            except SQLAlchemyError as e:
                log.error(f"Failed to update ReadBook status for book {book_id}: {e}")
                # Rollback only affects the failed ReadBook update
                # kosync_progress was already committed and is safe
                ub.session.rollback()
            except Exception as e:
                log.error(f"Unexpected error updating ReadBook status for book {book_id}: {e}")
                ub.session.rollback()

        return create_sync_response(response_data)

    except KOSyncError as e:
        return handle_sync_error(e)
    except SQLAlchemyError as e:
        log.error(f"update_progress: Database error: {str(e)}")
        ub.session.rollback()
        return handle_sync_error(KOSyncError(ERROR_INTERNAL, "Database error"))
    except Exception as e:
        log.error(f"update_progress: Unexpected error: {str(e)}")
        ub.session.rollback()
        return handle_sync_error(KOSyncError(ERROR_INTERNAL, "Internal server error"))


################################################################################
# Error Handlers
################################################################################

@kosync.errorhandler(400)
def handle_bad_request(error):
    """Handle HTTP 400 Bad Request errors"""
    return create_sync_response({
        "error": ERROR_INVALID_FIELDS,
        "message": "Bad request"
    }, 400)


@kosync.errorhandler(401)
def handle_unauthorized(error):
    """Handle HTTP 401 Unauthorized errors"""
    return create_sync_response({
        "error": ERROR_UNAUTHORIZED_USER,
        "message": "Unauthorized"
    }, 401)


@kosync.errorhandler(500)
def handle_internal_error(error):
    """Handle HTTP 500 Internal Server errors"""
    log.error(f"Internal server error: {error}")
    return create_sync_response({
        "error": ERROR_INTERNAL,
        "message": "Internal server error"
    }, 500)
